--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ea0ffb316f91e4d8a5e4585e7947f329c52ac862
Parents : 5684344
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-29T05:51:54-05:00
feat(interface-stats): fix serialization of interface stats and update Vue component to use new naming convention
Changes
3 files changed, 104 insertions(+), 42 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index b21cb246..8da1dfc7 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -2237,6 +2237,16 @@ class ReticulumMeshChat:
else:
interface_details.pop("bootstrap_only", None)
+ @staticmethod
+ def _to_jsonable(obj):
+ if isinstance(obj, bytes):
+ return obj.hex()
+ if isinstance(obj, dict):
+ return {k: ReticulumMeshChat._to_jsonable(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [ReticulumMeshChat._to_jsonable(v) for v in obj]
+ return obj
+
@staticmethod
def discovery_filter_candidates(interface):
if not isinstance(interface, dict):
@@ -11718,50 +11728,18 @@ class ReticulumMeshChat:
# get interface stats
@routes.get("/api/v1/interface-stats")
async def interface_stats(request):
- # get interface stats
interface_stats = {"interfaces": []}
if hasattr(self, "reticulum") and self.reticulum:
try:
- interface_stats = self.reticulum.get_interface_stats()
-
- # ensure transport_id is hex as json_response can't serialize bytes
- if "transport_id" in interface_stats:
- interface_stats["transport_id"] = interface_stats[
- "transport_id"
- ].hex()
-
- # ensure probe_responder is hex as json_response can't serialize bytes
- if (
- "probe_responder" in interface_stats
- and interface_stats["probe_responder"] is not None
- ):
- interface_stats["probe_responder"] = interface_stats[
- "probe_responder"
- ].hex()
-
- # ensure ifac_signature is hex as json_response can't serialize bytes
- for interface in interface_stats["interfaces"]:
- if "short_name" in interface:
- interface["interface_name"] = interface["short_name"]
-
- if (
- "parent_interface_name" in interface
- and interface["parent_interface_name"] is not None
- ):
- interface["parent_interface_hash"] = interface[
- "parent_interface_hash"
- ].hex()
-
- if interface.get("ifac_signature"):
- interface["ifac_signature"] = interface[
- "ifac_signature"
- ].hex()
-
- try:
- if interface.get("hash"):
- interface["hash"] = interface["hash"].hex()
- except Exception:
- pass
+ raw = self.reticulum.get_interface_stats()
+ if isinstance(raw, dict):
+ interface_stats = self._to_jsonable(raw)
+ for interface in interface_stats.get("interfaces") or []:
+ if (
+ isinstance(interface, dict)
+ and "short_name" in interface
+ ):
+ interface["interface_name"] = interface["short_name"]
except Exception:
pass
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 74d53b29..073ce286 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -995,7 +995,10 @@ export default {
// update data
const interfaces = response.data.interface_stats?.interfaces ?? [];
for (const iface of interfaces) {
- this.interfaceStats[iface.short_name] = iface;
+ const key = iface.interface_name ?? iface.short_name;
+ if (key) {
+ this.interfaceStats[key] = iface;
+ }
}
} catch {
// do nothing if failed to load interfaces
diff --git a/tests/backend/test_interface_stats_endpoint.py b/tests/backend/test_interface_stats_endpoint.py
new file mode 100644
index 00000000..a21968f2
--- /dev/null
+++ b/tests/backend/test_interface_stats_endpoint.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+
+@pytest.fixture
+def temp_dir():
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture
+def mock_identity():
+ mock_id = MagicMock()
+ mock_id.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id.hexhash = mock_id.hash.hex()
+ mock_id.get_private_key.return_value = b"test_private_key"
+ return mock_id
+
+
+@pytest.mark.asyncio
+async def test_interface_stats_serializes_bytes_and_parent_hash_null(
+ mock_identity, temp_dir
+):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app_instance = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ app_instance.reticulum = MagicMock()
+ app_instance.reticulum.get_interface_stats.return_value = {
+ "interfaces": [
+ {
+ "short_name": "Child",
+ "status": True,
+ "parent_interface_name": "Parent",
+ "parent_interface_hash": None,
+ "hash": b"\x01" * 16,
+ "ifac_signature": b"\x02" * 16,
+ },
+ {
+ "short_name": "Main",
+ "status": True,
+ "hash": b"\x03" * 16,
+ },
+ ],
+ "transport_id": b"\x04" * 16,
+ "network_id": b"\x05" * 16,
+ "probe_responder": b"\x06" * 16,
+ }
+
+ handler = None
+ for route in app_instance.get_routes():
+ if route.path == "/api/v1/interface-stats" and route.method == "GET":
+ handler = route.handler
+ break
+
+ assert handler is not None
+
+ response = await handler(MagicMock())
+ assert response.status == 200
+ data = json.loads(response.body)
+ stats = data["interface_stats"]
+
+ assert stats["transport_id"] == ("04" * 16)
+ assert stats["network_id"] == ("05" * 16)
+ assert stats["probe_responder"] == ("06" * 16)
+ assert len(stats["interfaces"]) == 2
+ assert stats["interfaces"][0]["interface_name"] == "Child"
+ assert stats["interfaces"][0]["parent_interface_hash"] is None
+ assert stats["interfaces"][0]["hash"] == ("01" * 16)
+ assert stats["interfaces"][1]["short_name"] == "Main"
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────